Table of Contents

    *이 글의 코드는 go 언어로 작성되었습니다.


    들어가는 글

    사내에서 향후 개발할 프로젝트에 대한 전반적인 브리핑을 받을 때 처음으로 Visitor Pattern 이란 용어를 접했습니다. 제가 개발할 프로젝트는 다양한 게이미피케이션 피쳐를 제공하는 시스템으로, 현재는 2개만 개발되어 있는 게임에서 지속적으로 다양한 종류의 게임을 추가해 나가는 목표를 가지고 있습니다. 예를 들어 현재는 가위바위보, 랜덤뽑기만 구현된 시스템에서 앞으로는 룰렛, 스탬프, 출석체크 등 다양한 게임을 추가해 나가는 로드맵을 가지고 있습니다. 각각의 게임들은 서로 다른 규칙과 데이터 구조를 가지고 있지만, 보상 계산, 게임 상태 체크, 진행률 추적과 같은 공통 기능이 필요합니다. 더불어 이러한 공통 기능은 앞으로도 새롭게 추가될 가능성이 있습니다. 이러한 공통 기능을 추가하는 방법은 두 가지가 있습니다.

    1. 각각의 게임에 공통적으로 필요한 기능을 모두 구현하거나,
      • 개별 게임 도메인 마다 calculateReward, checkGameStatus, trackProgress 등의 메서드를 구현
      • 새로운 공통 기능이 추가될 때 마다 모든 게임 클래스를 수정해야 함.
    2. 공통적으로 필요한 기능을 모두 외부에 구현한 후 각각의 게임에서 기능을 호출하는 식으로 구현할 수 있습니다.
      • RewardCalculator, GameStateChecker, ProgressTracker 등의 Visitor 클래스를 만들어 기능을 캡슐화
      • 새로운 게임이 추가되어도 기존 기능의 코드를 수정할 필요가 없음
      • 새로운 공통 기능을 추가할 때 새로운 Visitor 클래스만 만들면 됨

    현재 저희 팀에서는 두 번째 방법인 Visitor Pattern 으로 필요한 공통 기능을 구현하고 있습니다. 이 글에서는 Visitor Pattern 의 특징과 다른 방법에 비해 어떠한 장점이 있는지 중점적으로 다뤄보고자 합니다.


    패턴의 구조

    패턴의 전반적인 구조를 알기 전에 그림으로 대략적인 내용을 파악해보겠습니다. image 룰렛 게임과 스탬프 게임은 서로 다른 게임 로직을 가지지만, 비즈니스 로직상 공통적으로 수행되어야 하는 부분 역시 존재합니다. 일례로 당첨 결과를 확인하는 로직은 두 게임에서 모두 동일하게 수행되어야 하며, 이러한 공통의 로직은 지속적으로 추가될 수 있습니다. 이러한 공통 로직을 개별 도메인(Roulette, Stamp)에 직접 구현하기 보다 외부에 방문(Visitor)하여 필요한 기능을 사용(accept)하는 방향으로 설계하는 것이 Visitor Pattern 입니다.


    우선 Visitor 인터페이스를 정의해보겠습니다.

    Visitor Interface
    type Visitor interface {
      VisitRoulette(*Roulette)
      VisitStamp(*Stamp)
    }

    Visitor 인터페이스에서 각각의 게임 구조체를 방문할 수 있는 메서드를 선언합니다.(Go 언어에서는 class 대신 struct 를 사용합니다.) 구조체를 방문한다는 것은 공통 로직이 정의되어 있는 구조체에 접근하여 필요한 기능을 사용하는 것을 의미합니다. 메서드 이름은 보통 visit + 구조체 이름으로 구성되며 각 visit method 는 구조체 타입을 인자로 받습니다.

    RewardCalculator
    type RewardCalculator struct {
        reward float64
    }
    
    func (r *RewardCalculator) visitRoulette(roulette *Roulette) {
        // Roulette 의 보상 계산 로직
    }
    
    func (r *RewardCalculator) visitStamp(stamp *Stamp) {
        // Stamp 의 보상 계산 로직
    }

    첫번째 공통 로직으로 리워드 계산을 구현해보기로 합시다. RewardCalculator 는 Visitor 인터페이스의 구현체로 각각의 게임 구조체를 방문할 수 있는 메서드를 구현하고 있습니다. 가령 visitRoulette 메서드는 Roulette 구조체를 인자로 받아 룰렛 게임의 보상 계산 로직을 구현하며, visitStamp 메서드 역시 Stamp 구조체를 인자로 받아 스탬프 게임의 보상 계산 로직을 구현합니다.

    Roulette 혹은 Stamp 도메인에서는 직접 보상 로직을 구현할 필요없이 RewardCalculator 를 사용(방문)하여 보상 계산을 수행할 수 있습니다. 그럼 Roulette 에서는 어떻게 위 로직을 호출하는지 코드로 확인해보겠습니다.

    Roulette
    type Roulette struct {
        rouletteData string
    }
    
    // 룰렛 자체 로직
    func (r *Roulette) playRoulette() {
        // 룰렛 게임 로직
    }
    
    // 공통 기능 호출 -> Visitor 의 적절한 visit method 호출
    func (r *Roulette) accept(v Visitor) {
        v.visitRoulette(r)
    }

    위의 정의된 playRoulette 메서드는 룰렛의 자체 도메인 로직이며, accept 메서드는 공통 기능을 호출을 담당하고 있습니다. 호출해야 할 공통 로직이 여러개 있을 경우 어떻게 accept() 만으로 호출이 가능한지 이해가 어려울 수 도 있는데요, 아래 코드를 보면서 게속 이해해보겠습니다.

    func main() {
       // 게임 인스턴스 생성
       r := &Roulette{rouletteData: "STAGE_2"}
    
       // 보상 계산기 생성
       calculator := &RewardCalculator{}
    
       // 각 게임의 보상 계산
       r.accept(calculator)
       fmt.Printf("Roulette Reward: %.2f points\n", calculator.reward) // 난이도 보상
    
    }

    공통 로직 중 RewardCalculator 를 선언하여 accept() 의 인자로 넘겼습니다. accept() 메서드는 인자로 받은 Visitor 인터페이스의 구현체(RewardCalculator) 메서드(visitRoulette)를 호출합니다. 그럼 이제 RewardCalculator 에서 미리 구현해두었던 visitRoulette 메서드가 호출되며, 룰렛 게임의 보상 계산 로직을 수행하게 됩니다.


    공통 로직 추가 시 Visitor Pattern 의 장점

    위에서는 RewardCalculator 라는 공통 로직을 Visitor 패턴으로 구현했습니다. 여기서 공통 로직이 추가되는 경우 어떻게 될까요? Roulette 이나 Stamp 도메인 구조체를 수정할 필요가 있을까요? 눈치 채셨겠지만 이미 RewardCalculator 구현할 때도 외부에 책임을 구현했기 때문에 새로운 공통 로직을 추가하는 상황이더라도 도메인 코드를 수정할 필요가 전혀 없습니다.

    ProgressTracker
    type ProgressTracker struct {
        progress    float64
        totalSteps  int
        currentStep int
    }
    
    func (p *ProgressTracker) visitRoulette(roulette*Roulette) {
        // 룰렛 게임 진행률 계산 로직
    }
    
    func (p *ProgressTracker) visitStamp(stamp *Stamp) {
        // 스탬프 게임 진행률 계산 로직
    }

    ProgressTracker 는 게임의 진행률을 계산하는 공통 로직을 구현합니다. 진행률 계산 로직을 각각의 게임 구조체에 구현하는 것이 아닌, 게임 외부에 로직을 구현하여 도메인 코드를 수정하지 않고 공통 로직을 유연하게 추가할 수 있습니다. 아래 다이어그램 처럼 새로운 공통 로직이 추가되어도 기존 코드는 전혀 수정할 필요가 없습니다. 그저 새로운 공통 로직이 Visitor 인터페이스를 구현하도록 구현체를 추가하고, 기존 Roulette, Stamp 구조체에서 Visitor 의 적절한 visit method 를 호출하면 됩니다.

    image2

    func main() {
    
       r := &Roulette{rouletteData: "STAGE_2"}
    
       // 진행상태 추적기 생성
       tracker := &ProgressTracker{}
    
       // 진행상태 확인
       r.accept(tracker)
       fmt.Printf("Roulette Progress: %.1f%%\n", tracker.progress)
    
    }

    이전에는 RewardCalculator 를 accept() 의 인자로 넘겼다면, 이번에는 ProgressTracker 를 accept() 의 인자로 넘기면 됩니다. 이렇게 하나의 게임 구조체(Roulette, Stamp)에 여러 종류의 Visitor 를 사용할 수 있습니다. 각 Visitor 는 자신의 목적에 맞는 처리를 수행함과 동시에

    1. 게임 구조체의 코드는 전혀 수정할 필요가 없고,
    2. Visitor 구현체들도 서로 독립적으로 동작하며
    3. 공통 로직이 추가될 때 마다 새로운 Visitor 구현체를 만들면 되기 때문에 기존 코드에는 영향이 없습니다.

    만약 Visitor 패턴을 사용하지 않았다면?

    지금까지 Visitor 패턴을 사용하여 공통 로직을 어떻게 유연하게 확장할 수 있는지 살펴보았습니다. 하지만 과연 이러한 패턴을 사용하는 것이 필수적일까요? 언뜻보면 한눈에 이해되지 않는 boilerplate 코드를 작성해야 하기 때문에 오히려 복잡하고 어렵다고 생각할 수 있습니다. 그럼 Visitor 패턴을 사용하지 않고 위의 예시를 작성하면 어떤 식으로 코드가 작성되는지 살펴보겠습니다.

    type Roulette struct {
      ...
    }
    
    func (r *Roulette) CalculateReward() float64 {
        // Calculate rewards based on spins left and prize values
        return someReward
    }
    
    func (r *Roulette) ProgressTracker() float64 {
        // Track progress based on spins used
        return someProgress
    }
    
    type StampCollection struct {
      ...
    }
    
    func (s *StampCollection) CalculateReward() float64 {
        // Calculate rewards based on stamps collected
        return someReward
    }
    
    func (s *StampCollection) ProgressTracker() float64 {
        // Track progress based on stamps collected vs total
        return float64(s.stampsCollected) / float64(s.totalStamps)
    }

    현재 방식에선 공통적으로 사용되는 CalculateReward, ProgressTracker 메서드를 각각의 게임 구조체에 직접 구현하고 있습니다. Visitor 패턴과 달리 더 직관적이고 단순하게 코드를 작성할 수 있다는 장점이 존재하며, 보일러플레이트 코드 역시 줄일 수 있어 설계 역시 단순해집니다. 하지만 이러한 방식은 공통 로직이 추가될 때 마다 모든 게임 구조체를 직접 수정해야 하며 관련 기능들이 여러 구조체에 분산되어 응집도가 떨어지는 문제도 존재합니다. 여러 개발자가 작업할 경우 비슷한 기능 간의 일관성 유지 역시 어려워질 가능성도 있습니다. Visitor 패턴을 사용하지 않은 코드를 살펴보니 애매하게 보였던 Visitor 패턴의 장점과 단점이 조금은 선명해지는 느낌입니다.

    Visitor Pattern 장점
    • 게임 구조체 수정 없이 새로운 공통 기능 추가 가능
    • 관련 기능들이 한곳에 모여있다
    • 기능 간의 일관성 유지에 용이하다
    • 관심사의 분리가 잘 됨
    Visitor Pattern 단점
    • 더 복잡한 구조
    • 초기 보일러플레이트 코드가 많다
    • 간단한 케이스에서는 과도할 수 있다
    • 패턴에 익숙하지 않은 개발자들이 이해하기 어려울 수 있다

    마무리

    결국 어떤 접근 방식을 선택하느냐는 프로젝트의 요구사항에 맞게 선택하는 것이 중요하다고 생각합니다.
    저희 팀에서는 아래와 같은 이유로 Visitor Pattern을 채택했다고 생각합니다.

    1. 향후 많은 기능 추가 예상
    2. 모든 게임에 걸친 복잡한 분석이나 리포트가 필요
    3. 타 팀 혹은 시스템과 연결되는 다양한 요구사항 고려
    4. 기능의 일관성과 유지보수성이 중요한 프로젝트

    Visitor Pattern 은 확장성과 유지보수성이 중요한 프로젝트에서 큰 강점을 발휘할 수 있습니다. 특히 미니게임 처럼 다양한 게임이 지속적으로 추가되고, 각 기능 간의 일관성이 중요한 도메인에서 더욱 유용할 수 있습니다. 하지만 작은 규모의 프로젝트나, 기능 확장이 제한적인 경우에는 오히려 직접 구현 방식이 더 적합할 수 있습니다.

    결론적으로, Visitor Pattern은 확장성유지보수성 이라는 장점을 제공하지만, 이는 코드의 복잡성이라는 비용을 감수해야 얻을 수 있습니다. 팀원과의 합의도 필요하겠죠. 어쩌면 확장성과 유지보수성이라는 대가로 더 큰 비용을 치뤄야 할지도 모릅니다. 따라서 프로젝트의 규모, 확장 가능성, 팀의 구성 등을 종합적으로 고려하여 패턴 적용을 결정하면 좋겠습니다.